import { Scalar } from '../nodes/Scalar.ts'
import type { CollectionTag, ScalarTag } from '../schema/types.ts'
import {
  FOLD_BLOCK,
  FOLD_FLOW,
  FOLD_QUOTED,
  foldFlowLines,
  type FoldOptions
} from './foldFlowLines.ts'
import type { StringifyContext } from './stringify.ts'

interface StringifyScalar {
  value: string
  comment?: string | null
  type?: string
}

const getFoldOptions = (
  ctx: StringifyContext,
  isBlock: boolean
): FoldOptions => ({
  indentAtStart: isBlock ? ctx.indent.length : ctx.indentAtStart,
  lineWidth: ctx.options.lineWidth,
  minContentWidth: ctx.options.minContentWidth
})

// Also checks for lines starting with %, as parsing the output as YAML 1.1 will
// presume that's starting a new document.
const containsDocumentMarker = (str: string) => /^(%|---|\.\.\.)/m.test(str)

function lineLengthOverLimit(
  str: string,
  lineWidth: number,
  indentLength: number
) {
  if (!lineWidth || lineWidth < 0) return false
  const limit = lineWidth - indentLength
  const strLen = str.length
  if (strLen <= limit) return false
  for (let i = 0, start = 0; i < strLen; ++i) {
    if (str[i] === '\n') {
      if (i - start > limit) return true
      start = i + 1
      if (strLen - start <= limit) return false
    }
  }
  return true
}

function doubleQuotedString(value: string, ctx: StringifyContext) {
  const json = JSON.stringify(value)
  if (ctx.options.doubleQuotedAsJSON) return json

  const { implicitKey } = ctx
  const minMultiLineLength = ctx.options.doubleQuotedMinMultiLineLength
  const indent = ctx.indent || (containsDocumentMarker(value) ? '  ' : '')
  let str = ''
  let start = 0
  for (let i = 0, ch = json[i]; ch; ch = json[++i]) {
    if (ch === ' ' && json[i + 1] === '\\' && json[i + 2] === 'n') {
      // space before newline needs to be escaped to not be folded
      str += json.slice(start, i) + '\\ '
      i += 1
      start = i
      ch = '\\'
    }
    if (ch === '\\')
      switch (json[i + 1]) {
        case 'u':
          {
            str += json.slice(start, i)
            const code = json.substr(i + 2, 4)
            switch (code) {
              case '0000':
                str += '\\0'
                break
              case '0007':
                str += '\\a'
                break
              case '000b':
                str += '\\v'
                break
              case '001b':
                str += '\\e'
                break
              case '0085':
                str += '\\N'
                break
              case '00a0':
                str += '\\_'
                break
              case '2028':
                str += '\\L'
                break
              case '2029':
                str += '\\P'
                break
              default:
                if (code.substr(0, 2) === '00') str += '\\x' + code.substr(2)
                else str += json.substr(i, 6)
            }
            i += 5
            start = i + 1
          }
          break
        case 'n':
          if (
            implicitKey ||
            json[i + 2] === '"' ||
            json.length < minMultiLineLength
          ) {
            i += 1
          } else {
            // folding will eat first newline
            str += json.slice(start, i) + '\n\n'
            while (
              json[i + 2] === '\\' &&
              json[i + 3] === 'n' &&
              json[i + 4] !== '"'
            ) {
              str += '\n'
              i += 2
            }
            str += indent
            // space after newline needs to be escaped to not be folded
            if (json[i + 2] === ' ') str += '\\'
            i += 1
            start = i + 1
          }
          break
        default:
          i += 1
      }
  }
  str = start ? str + json.slice(start) : json
  return implicitKey
    ? str
    : foldFlowLines(str, indent, FOLD_QUOTED, getFoldOptions(ctx, false))
}

function singleQuotedString(value: string, ctx: StringifyContext) {
  if (
    ctx.options.singleQuote === false ||
    (ctx.implicitKey && value.includes('\n')) ||
    /[ \t]\n|\n[ \t]/.test(value) // single quoted string can't have leading or trailing whitespace around newline
  )
    return doubleQuotedString(value, ctx)

  const indent = ctx.indent || (containsDocumentMarker(value) ? '  ' : '')
  const res =
    "'" + value.replace(/'/g, "''").replace(/\n+/g, `$&\n${indent}`) + "'"
  return ctx.implicitKey
    ? res
    : foldFlowLines(res, indent, FOLD_FLOW, getFoldOptions(ctx, false))
}

function quotedString(value: string, ctx: StringifyContext) {
  const { singleQuote } = ctx.options
  let qs
  if (singleQuote === false) qs = doubleQuotedString
  else {
    const hasDouble = value.includes('"')
    const hasSingle = value.includes("'")
    if (hasDouble && !hasSingle) qs = singleQuotedString
    else if (hasSingle && !hasDouble) qs = doubleQuotedString
    else qs = singleQuote ? singleQuotedString : doubleQuotedString
  }
  return qs(value, ctx)
}

// The negative lookbehind avoids a polynomial search,
// but isn't supported yet on Safari: https://caniuse.com/js-regexp-lookbehind
let blockEndNewlines: RegExp
try {
  blockEndNewlines = new RegExp('(^|(?<!\n))\n+(?!\n|$)', 'g')
} catch {
  blockEndNewlines = /\n+(?!\n|$)/g
}

function blockString(
  { comment, type, value }: StringifyScalar,
  ctx: StringifyContext,
  onComment?: () => void,
  onChompKeep?: () => void
) {
  const { blockQuote, commentString, lineWidth } = ctx.options
  // 1. Block can't end in whitespace unless the last line is non-empty.
  // 2. Strings consisting of only whitespace are best rendered explicitly.
  if (!blockQuote || /\n[\t ]+$/.test(value) || /^\s*$/.test(value)) {
    return quotedString(value, ctx)
  }

  const indent =
    ctx.indent ||
    (ctx.forceBlockIndent || containsDocumentMarker(value) ? '  ' : '')
  const literal =
    blockQuote === 'literal'
      ? true
      : blockQuote === 'folded' || type === Scalar.BLOCK_FOLDED
        ? false
        : type === Scalar.BLOCK_LITERAL
          ? true
          : !lineLengthOverLimit(value, lineWidth, indent.length)
  if (!value) return literal ? '|\n' : '>\n'

  // determine chomping from whitespace at value end
  let chomp: '' | '-' | '+'
  let endStart: number
  for (endStart = value.length; endStart > 0; --endStart) {
    const ch = value[endStart - 1]
    if (ch !== '\n' && ch !== '\t' && ch !== ' ') break
  }
  let end = value.substring(endStart)
  const endNlPos = end.indexOf('\n')
  if (endNlPos === -1) {
    chomp = '-' // strip
  } else if (value === end || endNlPos !== end.length - 1) {
    chomp = '+' // keep
    if (onChompKeep) onChompKeep()
  } else {
    chomp = '' // clip
  }
  if (end) {
    value = value.slice(0, -end.length)
    if (end[end.length - 1] === '\n') end = end.slice(0, -1)
    end = end.replace(blockEndNewlines, `$&${indent}`)
  }

  // determine indent indicator from whitespace at value start
  let startWithSpace = false
  let startEnd: number
  let startNlPos = -1
  for (startEnd = 0; startEnd < value.length; ++startEnd) {
    const ch = value[startEnd]
    if (ch === ' ') startWithSpace = true
    else if (ch === '\n') startNlPos = startEnd
    else break
  }
  let start = value.substring(
    0,
    startNlPos < startEnd ? startNlPos + 1 : startEnd
  )
  if (start) {
    value = value.substring(start.length)
    start = start.replace(/\n+/g, `$&${indent}`)
  }

  const indentSize = indent ? '2' : '1' // root is at -1
  // Leading | or > is added later
  let header = (startWithSpace ? indentSize : '') + chomp
  if (comment) {
    header += ' ' + commentString(comment.replace(/ ?[\r\n]+/g, ' '))
    if (onComment) onComment()
  }

  if (!literal) {
    const foldedValue = value
      .replace(/\n+/g, '\n$&')
      .replace(/(?:^|\n)([\t ].*)(?:([\n\t ]*)\n(?![\n\t ]))?/g, '$1$2') // more-indented lines aren't folded
      //                ^ more-ind. ^ empty     ^ capture next empty lines only at end of indent
      .replace(/\n+/g, `$&${indent}`)
    let literalFallback = false
    const foldOptions = getFoldOptions(ctx, true)
    if (blockQuote !== 'folded' && type !== Scalar.BLOCK_FOLDED) {
      foldOptions.onOverflow = () => {
        literalFallback = true
      }
    }
    const body = foldFlowLines(
      `${start}${foldedValue}${end}`,
      indent,
      FOLD_BLOCK,
      foldOptions
    )
    if (!literalFallback) return `>${header}\n${indent}${body}`
  }

  value = value.replace(/\n+/g, `$&${indent}`)
  return `|${header}\n${indent}${start}${value}${end}`
}

function plainString(
  item: StringifyScalar,
  ctx: StringifyContext,
  onComment?: () => void,
  onChompKeep?: () => void
) {
  const { type, value } = item
  const { actualString, implicitKey, indent, indentStep, inFlow } = ctx
  if (
    (implicitKey && value.includes('\n')) ||
    (inFlow && /[[\]{},]/.test(value))
  ) {
    return quotedString(value, ctx)
  }
  if (
    !value ||
    /^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test(
      value
    )
  ) {
    // not allowed:
    // - empty string, '-' or '?'
    // - start with an indicator character (except [?:-]) or /[?-] /
    // - '\n ', ': ' or ' \n' anywhere
    // - '#' not preceded by a non-space char
    // - end with ' ' or ':'
    return implicitKey || inFlow || !value.includes('\n')
      ? quotedString(value, ctx)
      : blockString(item, ctx, onComment, onChompKeep)
  }
  if (
    !implicitKey &&
    !inFlow &&
    type !== Scalar.PLAIN &&
    value.includes('\n')
  ) {
    // Where allowed & type not set explicitly, prefer block style for multiline strings
    return blockString(item, ctx, onComment, onChompKeep)
  }
  if (containsDocumentMarker(value)) {
    if (indent === '') {
      ctx.forceBlockIndent = true
      return blockString(item, ctx, onComment, onChompKeep)
    } else if (implicitKey && indent === indentStep) {
      return quotedString(value, ctx)
    }
  }
  const str = value.replace(/\n+/g, `$&\n${indent}`)
  // Verify that output will be parsed as a string, as e.g. plain numbers and
  // booleans get parsed with those types in v1.2 (e.g. '42', 'true' & '0.9e-3'),
  // and others in v1.1.
  if (actualString) {
    const test = (tag: CollectionTag | ScalarTag) =>
      tag.default && tag.tag !== 'tag:yaml.org,2002:str' && tag.test?.test(str)
    const { compat, tags } = ctx.doc.schema
    if (tags.some(test) || compat?.some(test)) return quotedString(value, ctx)
  }
  return implicitKey
    ? str
    : foldFlowLines(str, indent, FOLD_FLOW, getFoldOptions(ctx, false))
}

export function stringifyString(
  item: Scalar | StringifyScalar,
  ctx: StringifyContext,
  onComment?: () => void,
  onChompKeep?: () => void
): string {
  const { implicitKey, inFlow } = ctx
  const ss: StringifyScalar =
    typeof item.value === 'string'
      ? (item as StringifyScalar)
      : Object.assign({}, item, { value: String(item.value) })

  let { type } = item
  if (type !== Scalar.QUOTE_DOUBLE) {
    // force double quotes on control characters & unpaired surrogates
    if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u{D800}-\u{DFFF}]/u.test(ss.value))
      type = Scalar.QUOTE_DOUBLE
  }

  const _stringify = (_type: string | undefined) => {
    switch (_type) {
      case Scalar.BLOCK_FOLDED:
      case Scalar.BLOCK_LITERAL:
        return implicitKey || inFlow
          ? quotedString(ss.value, ctx) // blocks are not valid inside flow containers
          : blockString(ss, ctx, onComment, onChompKeep)
      case Scalar.QUOTE_DOUBLE:
        return doubleQuotedString(ss.value, ctx)
      case Scalar.QUOTE_SINGLE:
        return singleQuotedString(ss.value, ctx)
      case Scalar.PLAIN:
        return plainString(ss, ctx, onComment, onChompKeep)
      default:
        return null
    }
  }

  let res = _stringify(type)
  if (res === null) {
    const { defaultKeyType, defaultStringType } = ctx.options
    const t = (implicitKey && defaultKeyType) || defaultStringType
    res = _stringify(t)
    if (res === null) throw new Error(`Unsupported default string type ${t}`)
  }
  return res
}
